<!DOCTYPE html>
<html>
  <head>
      <meta http-equiv="content-type" content="text/html; charset=utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
  <meta content="yes" name="apple-mobile-web-app-capable" />
  <meta content="black" name="apple-mobile-web-app-status-bar-style" />
  <meta name="referrer" content="never">
  <meta name="keywords" content="">
  <meta name="description" content="">
  <meta name="author" content="kveln">
  <title>基于 Jenkins、Gitlab、Harbor、Helm 和 Kubernetes 的 CI/CD | John Wong&#39;s Blog</title>
  <link href="https://cdn.bootcss.com/twitter-bootstrap/4.3.1/css/bootstrap.min.css" rel="stylesheet">
  <!-- <link href="https://blog.jwangkun.com/media/css/bootstrap.min.css" rel="stylesheet"> -->
  <!--  <link href="https://blog.jwangkun.com/media/css/all.min.css" rel="stylesheet" type="text/css"> -->
  <link href="https://cdn.bootcss.com/font-awesome/5.11.2/css/all.min.css" rel="stylesheet">
  <link href='https://fonts.googleapis.com/css?family=Lora:400,700,400italic,700italic' rel='stylesheet' type='text/css'>
  <link href='https://fonts.googleapis.com/css?family=Open+Sans:300italic,400italic,600italic,700italic,800italic,400,300,600,700,800' rel='stylesheet' type='text/css'>
  <link rel="alternate" type="application/rss+xml" title="基于 Jenkins、Gitlab、Harbor、Helm 和 Kubernetes 的 CI/CD | John Wong&#39;s Blog » Feed" href="https://blog.jwangkun.com/atom.xml">
  <link rel="stylesheet"href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.15.10/build/styles/androidstudio.min.css">
  <link href="https://blog.jwangkun.com/styles/main.css" rel="stylesheet">
  <script src="https://cdn.bootcss.com/jquery/3.4.1/jquery.min.js"></script>
  <script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.15.10/build/highlight.min.js"></script>
  <!-- <script src="https://blog.jwangkun.com/media/scripts/jquery.min.js"></script> -->
  <script>hljs.initHighlightingOnLoad();</script>
  

    <meta property="og:description" content="基于 Jenkins、Gitlab、Harbor、Helm 和 Kubernetes 的 CI/CD"/>
    <meta property="og:url" content="https://blog.jwangkun.com/post/pyEGDeM0P/"/>
    <meta property="og:locale" content="zh-CN"/>
    <meta property="og:type" content="website"/>
    <meta property="og:site_name" content="John Wong&#39;s Blog"/>
  </head>
  <body>
  	<!-- Navigation -->
  <nav class="navbar navbar-expand-lg navbar-light fixed-top" id="mainNav">
    <div class="container">
      <a class="navbar-brand" href="https://blog.jwangkun.com">John Wong&#39;s Blog</a>
      <button class="navbar-toggler navbar-toggler-right" type="button" data-toggle="collapse" data-target="#navbarResponsive" aria-controls="navbarResponsive" aria-expanded="false" aria-label="Toggle navigation">
        Menu
        <i class="fas fa-bars"></i>
      </button>
      <div class="collapse navbar-collapse" id="navbarResponsive">
        <ul class="navbar-nav ml-auto">
          
          <li class="nav-item">
              
              <a class="nav-link" href="/">首页</a>
              
          </li>
          
          <li class="nav-item">
              
              <a class="nav-link" href="/archives">归档</a>
              
          </li>
          
          <li class="nav-item">
              
              <a class="nav-link" href="/tags">标签</a>
              
          </li>
          
          <li class="nav-item">
              
              <a class="nav-link" href="/post/about">关于</a>
              
          </li>
          
        </ul>
      </div>
    </div>
  </nav>
  <!-- Page Header -->
  <header class="masthead" style="background-image: url('https://blog.jwangkun.com/media/images/home-bg.jpg')">
    <div class="overlay"></div>
    <div class="container">
      <div class="row">
        <div class="col-lg-8 col-md-10 mx-auto">
          <div class="post-heading">
          	<span class="tags">
          	 
            <a href="https://blog.jwangkun.com/tag/An8-Q_-eS/" class="tag">Jenkins</a>
            
            <a href="https://blog.jwangkun.com/tag/CMrQd0atvo/" class="tag">Harbor</a>
            
            <a href="https://blog.jwangkun.com/tag/fPt8lnQmCT/" class="tag">CI/CD</a>
            
            <a href="https://blog.jwangkun.com/tag/efVGHoi38/" class="tag">Kubernetes</a>
            
        </span>
            <h1>基于 Jenkins、Gitlab、Harbor、Helm 和 Kubernetes 的 CI/CD</h1>
            <span class="meta">
            	Posted on
              2020-10-15，32 min read
            </span>
          </div>
        </div>
      </div>
    </div>
  </header>

  <!-- Post Content -->
  <article>
    <div class="container">
      <div class="row">
        <div class="col-lg-8 col-md-10 mx-auto">
          <p>我们利用 Kubernetes 来动态运行 Jenkins 的 Slave 节点，可以和好的来解决传统的 Jenkins Slave 浪费大量资源的缺点。之前的示例中我们是将项目放置在 Github 仓库上的，将 Docker 镜像推送到了 Docker Hub，这节课我们来结合我们前面学习的知识点来综合运用下，使用 Jenkins、Gitlab、Harbor、Helm、Kubernetes 来实现一个完整的持续集成和持续部署的流水线作业。</p>
<!-- more -->
<h2 id="流程">流程</h2>
<p>下图是我们当前示例的流程图</p>
<p><img src="https://bxdc-static.oss-cn-beijing.aliyuncs.com/images/Ntfu2v.jpg" alt="ci/cd demo" loading="lazy">ci/cd demo</p>
<ul>
<li>\1. 开发人员提交代码到 Gitlab 代码仓库</li>
<li>\2. 通过 Gitlab 配置的 Jenkins Webhook 触发 Pipeline 自动构建</li>
<li>\3. Jenkins 触发构建构建任务，根据 Pipeline 脚本定义分步骤构建</li>
<li>\4. 先进行代码静态分析，单元测试</li>
<li>\5. 然后进行 Maven 构建（Java 项目）</li>
<li>\6. 根据构建结果构建 Docker 镜像</li>
<li>\7. 推送 Docker 镜像到 Harbor 仓库</li>
<li>\8. 触发更新服务阶段，使用 Helm 安装/更新 Release</li>
<li>\9. 查看服务是否更新成功。</li>
</ul>
<h2 id="项目">项目</h2>
<p>本次示例项目是一个完整的基于 Spring Boot、Spring Security、JWT、React 和 Ant Design 构建的一个开源的投票应用，项目地址：https://github.com/callicoder/spring-security-react-ant-design-polls-app。</p>
<p><img src="https://bxdc-static.oss-cn-beijing.aliyuncs.com/images/o9SWfn.jpg" alt="polling app1" loading="lazy">polling app1</p>
<p>我们将会在该项目的基础上添加部分代码，并实践 CI/CD 流程。</p>
<h3 id="服务端">服务端</h3>
<p>首先需要更改的是服务端配置，我们需要将数据库链接的配置更改成环境变量的形式，写死了的话就没办法进行定制了，修改服务端文件<code>src/main/resources/application.properties</code>，将下面的数据库配置部分修改成如下形式：</p>
<pre><code>spring.datasource.url= jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:polling_app}?useSSL=false&amp;serverTimezone=UTC&amp;useLegacyDatetimeCode=false
spring.datasource.username= ${DB_USER:root}
spring.datasource.password= ${DB_PASSWORD:root}
</code></pre>
<p>当环境变量中有上面的数据配置的时候，就会优先使用环境变量中的值，没有的时候就会用默认的值进行数据库配置。由于我们要将项目部署到 Kubernetes 集群中去，所以我们需要将服务端进行容器化，所以我们在项目根目录下面添加一个<code>Dockerfile</code>文件进行镜像构建：</p>
<pre><code class="language-docker">FROM openjdk:8-jdk-alpine

MAINTAINER cnych &lt;icnych@gmail.com&gt;

ENV LANG en_US.UTF-8
ENV LANGUAGE en_US:en
ENV LC_ALL en_US.UTF-8
ENV TZ=Asia/Shanghai

RUN mkdir /app

WORKDIR /app

COPY target/polls-0.0.1-SNAPSHOT.jar /app/polls.jar

EXPOSE 8080

ENTRYPOINT [&quot;java&quot;, &quot;-Djava.security.egd=file:/dev/./urandom&quot;, &quot;-jar&quot;,&quot;/app/polls.jar&quot;]
</code></pre>
<p>由于服务端代码是基于<code>Spring Boot</code>构建的，所以我们这里使用一个<code>openjdk</code>的基础镜像，将打包过后的<code>jar</code>包放入镜像之中，然后用过<code>java -jar</code>命令直接启动即可，这里就会存在一个问题了，我们是在 Jenkins 的 Pipeline 中去进行镜像构建的，这个时候项目中并没有打包好的<code>jar</code>包文件，那么我们应该如何获取打包好的<code>jar</code>包文件呢？这里我们可以使用两种方法：</p>
<p>第一种就是如果你用于镜像打包的 Docker 版本大于<code>17.06</code>版本的话，那么我墙裂推荐你使用 Docker 的多阶段构建功能来完成镜像的打包过程，我们只需要将上面的<code>Dockerfile</code>文件稍微更改下即可，将使用<code>maven</code>进行构建的工作放到同一个文件中：</p>
<pre><code class="language-docker">FROM maven:3.6-alpine as BUILD

COPY src /usr/app/src
COPY pom.xml /usr/app

RUN mvn -f /usr/app/pom.xml clean package -Dmaven.test.skip=true

FROM openjdk:8-jdk-alpine

MAINTAINER cnych &lt;icnych@gmail.com&gt;

ENV LANG en_US.UTF-8
ENV LANGUAGE en_US:en
ENV LC_ALL en_US.UTF-8
ENV TZ=Asia/Shanghai

RUN mkdir /app

WORKDIR /app

COPY --from=BUILD /usr/app/target/polls-0.0.1-SNAPSHOT.jar /app/polls.jar

EXPOSE 8080

ENTRYPOINT [&quot;java&quot;, &quot;-Djava.security.egd=file:/dev/./urandom&quot;, &quot;-jar&quot;,&quot;/app/polls.jar&quot;]
</code></pre>
<p>前面课程中我们就讲解过 Docker 的多阶段构建，这里我们定义了两个阶段，第一个阶段利用<code>maven:3.6-alpine</code>这个基础镜像将我们的项目进行打包，然后将该阶段打包生成的<code>jar</code>包文件复制到第二阶段进行最后的镜像打包，这样就可以很好的完成我们的 Docker 镜像的构建工作。</p>
<p>第二种方式就是我们传统的方式，在 Jenkins Pipeline 中添加一个<code>maven</code>构建的阶段，然后在第二个 Docker 构建的阶段就可以直接获取到前面的<code>jar</code>包了，也可以很方便的完成镜像的构建工作，为了更加清楚的说明 Jenkins Pipeline 的用法，我们这里采用这种方式，所以 Dockerfile 文件还是使用第一个就行。</p>
<p>现在我们可以将服务端的代码推送到 Gitlab 上去，我们这里的仓库地址为：http://git.qikqiak.com/course/polling-app-server.git</p>
<blockquote>
<p>注意，这里我们只推送的服务端代码。</p>
</blockquote>
<h3 id="客户端">客户端</h3>
<p>客户端我们需要修改 API 的链接地址，修改文件<code>src/constants/index.js</code>中<code>API_BASE_URL</code>的地址，我们同样通过环境变量来进行区分，如果有环境变量<code>APISERVER_URL</code>，则优先使用这个环境变量来作为 API 请求的地址：</p>
<pre><code class="language-javascript">let API_URL = 'http://localhost:8080/api';
if (process.env.APISERVER_URL) {
    API_URL = `${process.env.APISERVER_URL}/api`;
}
export const API_BASE_URL = API_URL;
</code></pre>
<p>因为我们这里的项目使用的就是前后端分离的架构，所以我们同样需要将前端代码进行单独的部署，同样我们要将项目部署到 Kubernetes 环境中，所以也需要做容器化，同样在项目根目录下面添加一个<code>Dockerfile</code>文件：</p>
<pre><code class="language-docker">FROM nginx:1.15.10-alpine
ADD build /usr/share/nginx/html

ADD nginx.conf
/etc/nginx/conf.d/default.conf
</code></pre>
<p>由于前端页面是单纯的静态页面，所以一般我们使用一个<code>nginx</code>镜像来运行，所以我们提供一个<code>nginx.conf</code>配置文件：</p>
<pre><code>server {
    gzip on;

    listen       80;
    server_name  localhost;

    root   /usr/share/nginx/html;
    location / {
        try_files $uri /index.html;
        expires 1h;
    }

    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   /usr/share/nginx/html;
    }

}
</code></pre>
<p>这里我们可以看到我们需要将前面页面打包到一个<code>build</code>目录，然后将改目录添加到 nginx 镜像中的<code>/usr/share/nginx/html</code>目录，这样当 nginx 镜像启动的时候就是直接使用的改文件夹下面的文件。</p>
<p>所以现在我们需要获取打包后的目录<code>build</code>，同样的，和上面服务端项目一样，我们可以使用两种方式来完成这个工作。</p>
<p>第一种方式自然是推荐的 Docker 的多阶段构建，我们在一个<code>node</code>镜像的环境中就可以打包我们的前端项目了，所以我们可以更改下<code>Dockerfile</code>文件，先进行 node 打包，然后再进行 nginx 启动：</p>
<pre><code class="language-docker">FROM node:alpine as BUILD

WORKDIR /usr/src/app

RUN mkdir -p /usr/src/app

ADD . /usr/src/app

RUN npm install &amp;&amp; \
    npm run build

FROM nginx:1.15.10-alpine
MAINTAINER cnych &lt;icnych@gmail.com&gt;

COPY --from=BUILD /usr/src/app/build /usr/share/nginx/html

ADD nginx.conf
/etc/nginx/conf.d/default.conf
</code></pre>
<p>第二种方式和上面一样在 Jenkins Pipeline 中添加一个打包构建的阶段即可，我们这里采用这种方式，所以 Dockerfile 文件还是使用第一个就行。</p>
<p>现在我们可以将客户端的代码推送到 Gitlab 上去，我们这里的仓库地址为：http://git.qikqiak.com/course/polling-app-client.git</p>
<h2 id="jenkins">Jenkins</h2>
<p>现在项目准备好了，接下来我们可以开始 Jenkins 的配置，还记得前面在 Pipeline 结合 Kubernetes 的课程中我们使用了一个<code>kubernetes</code>的 Jenkins 插件，但是之前使用的方式有一些不妥的地方，我们 Jenkins Pipeline 构建任务绑定到了一个固定的 Slave Pod 上面，这样就需要我们的 Slave Pod 中必须包含一系列构建所需要的依赖，比如 docker、maven、node、java 等等，这样就难免需要我们自己定义一个很庞大的 Slave 镜像，我们直接直接在 Pipeline 中去自定义 Slave Pod 中所需要用到的容器模板，这样我们需要什么镜像只需要在 Slave Pod Template 中声明即可，完全不需要去定义一个庞大的 Slave 镜像了。</p>
<p>首先去掉 Jenkins 中 kubernetes 插件中的 Pod Template 的定义，Jenkins -&gt; 系统管理 -&gt; 系统设置 -&gt; 云 -&gt; Kubernetes区域，删除下方的<code>Kubernetes Pod Template</code> -&gt; 保存。</p>
<p><img src="https://bxdc-static.oss-cn-beijing.aliyuncs.com/images/Vsj2qw.jpg" alt="jenkins kubernetes plugin" loading="lazy">jenkins kubernetes plugin</p>
<p>然后新建一个名为<code>polling-app-server</code>类型为<code>流水线(Pipeline)</code>的任务：</p>
<p><img src="https://bxdc-static.oss-cn-beijing.aliyuncs.com/images/RoMp9h.jpg" alt="new pipeline task" loading="lazy">new pipeline task</p>
<p>然后在这里需要勾选<code>触发远程构建</code>的触发器，其中令牌我们可以随便写一个字符串，然后记住下面的 URL，将 JENKINS_URL 替换成 Jenkins 的地址,我们这里的地址就是：<code>http://jenkins.qikqiak.com/job/polling-app-server/build?token=server321</code></p>
<p><img src="https://bxdc-static.oss-cn-beijing.aliyuncs.com/images/RUNtx9.jpg" alt="trigger" loading="lazy">trigger</p>
<p>然后在下面的<code>流水线</code>区域我们可以选择<code>Pipeline script</code>然后在下面测试流水线脚本，我们这里选择<code>Pipeline script from SCM</code>，意思就是从代码仓库中通过<code>Jenkinsfile</code>文件获取<code>Pipeline script</code>脚本定义，然后选择 SCM 来源为<code>Git</code>，在出现的列表中配置上仓库地址<code>http://git.qikqiak.com/course/polling-app-server.git</code>，由于我们是在一个 Slave Pod 中去进行构建，所以如果使用 SSH 的方式去访问 Gitlab 代码仓库的话就需要频繁的去更新 SSH-KEY，所以我们这里采用直接使用用户名和密码的形式来方式：</p>
<p><img src="https://bxdc-static.oss-cn-beijing.aliyuncs.com/images/W1aphT.jpg" alt="pipeline scm" loading="lazy">pipeline scm</p>
<p>在<code>Credentials</code>区域点击<code>添加</code>按钮添加我们访问 Gitlab 的用户名和密码：</p>
<p><img src="https://bxdc-static.oss-cn-beijing.aliyuncs.com/images/dsvTL6.jpg" alt="gitlab auth" loading="lazy">gitlab auth</p>
<p>然后需要我们配置用于构建的分支，如果所有的分支我们都想要进行构建的话，只需要将<code>Branch Specifier</code>区域留空即可，一般情况下不同的环境对应的分支才需要构建，比如 master、develop、test 等，平时开发的 feature 或者 bugfix 的分支没必要频繁构建，我们这里就只配置 master 和 develop 两个分支用户构建：</p>
<p><img src="https://bxdc-static.oss-cn-beijing.aliyuncs.com/images/2LRY2a.jpg" alt="gitlab branch config" loading="lazy">gitlab branch config</p>
<p>然后前往 Gitlab 中配置项目<code>polling-app-server</code> Webhook，settings -&gt; Integrations，填写上面得到的 trigger 地址：</p>
<p><img src="https://bxdc-static.oss-cn-beijing.aliyuncs.com/images/ZjGmdi.jpg" alt="webhook" loading="lazy">webhook</p>
<p>保存后，可以直接点击<code>Test</code> -&gt; <code>Push Event</code>测试是否可以正常访问 Webhook 地址，这里需要注意的是我们需要配置下 Jenkins 的安全配置，否则这里的触发器没权限访问 Jenkins，系统管理 -&gt; 全局安全配置：取消<code>防止跨站点请求伪造</code>，勾选上<code>匿名用户具有可读权限</code>：</p>
<p><img src="https://bxdc-static.oss-cn-beijing.aliyuncs.com/images/j2Vtrt.jpg" alt="security config" loading="lazy">security config</p>
<p>如果测试出现了<code>Hook executed successfully: HTTP 201</code>则证明 Webhook 配置成功了，否则就需要检查下 Jenkins 的安全配置是否正确了。</p>
<p>配置成功后我们只需要往 Gitlab 仓库推送代码就会触发 Pipeline 构建了。接下来我们直接在服务端代码仓库根目录下面添加<code>Jenkinsfile</code>文件，用于描述流水线构建流程。首先定义最简单的流程，要注意这里和前面课程的不同之处，这里我们使用<code>podTemplate</code>来定义不同阶段使用的的容器，有哪些阶段呢？</p>
<p>Clone 代码 -&gt; 代码静态分析 -&gt; 单元测试 -&gt; Maven 打包 -&gt; Docker 镜像构建/推送 -&gt; Helm 更新服务。</p>
<p>Clone 代码在默认的 Slave 容器中即可；静态分析和单元测试我们这里直接忽略，有需要这个阶段的同学自己添加上即可；Maven 打包肯定就需要 Maven 的容器了；Docker 镜像构建/推送是不是就需要 Docker 环境了呀；最后的 Helm 更新服务是不是就需要一个有 Helm 的容器环境了，所以我们这里就可以很简单的定义<code>podTemplate</code>了，如下定义：(添加一个<code>kubectl</code>工具用于测试)</p>
<pre><code class="language-groovy">def label = &quot;slave-${UUID.randomUUID().toString()}&quot;

podTemplate(label: label, containers: [
  containerTemplate(name: 'maven', image: 'maven:3.6-alpine', command: 'cat', ttyEnabled: true),
  containerTemplate(name: 'docker', image: 'docker', command: 'cat', ttyEnabled: true),
  containerTemplate(name: 'kubectl', image: 'cnych/kubectl', command: 'cat', ttyEnabled: true),
  containerTemplate(name: 'helm', image: 'cnych/helm', command: 'cat', ttyEnabled: true)
], volumes: [
  hostPathVolume(mountPath: '/root/.m2', hostPath: '/var/run/m2'),
  hostPathVolume(mountPath: '/home/jenkins/.kube', hostPath: '/root/.kube'),
  hostPathVolume(mountPath: '/var/run/docker.sock', hostPath: '/var/run/docker.sock')
]) {
  node(label) {
    def myRepo = checkout scm
    def gitCommit = myRepo.GIT_COMMIT
    def gitBranch = myRepo.GIT_BRANCH

    stage('单元测试') {
      echo &quot;测试阶段&quot;
    }
    stage('代码编译打包') {
      container('maven') {
        echo &quot;打码编译打包阶段&quot;
      }
    }
    stage('构建 Docker 镜像') {
      container('docker') {
        echo &quot;构建 Docker 镜像阶段&quot;
      }
    }
    stage('运行 Kubectl') {
      container('kubectl') {
        echo &quot;查看 K8S 集群 Pod 列表&quot;
        sh &quot;kubectl get pods&quot;
      }
    }
    stage('运行 Helm') {
      container('helm') {
        echo &quot;查看 Helm Release 列表&quot;
        sh &quot;helm list&quot;
      }
    }
  }
}
</code></pre>
<p>上面这段<code>groovy</code>脚本比较简单，我们需要注意的是<code>volumes</code>区域的定义，将容器中的<code>/root/.m2</code>目录挂载到宿主机上是为了给<code>Maven</code>构建添加缓存的，不然每次构建的时候都需要去重新下载依赖，这样就非常慢了；挂载<code>.kube</code>目录是为了能够让<code>kubectl</code>和<code>helm</code>两个工具可以读取到 Kubernetes 集群的连接信息，不然我们是没办法访问到集群的；最后挂载<code>/var/run/docker.sock</code>文件是为了能够让我们的<code>docker</code>这个容器获取到<code>Docker Daemon</code>的信息的，因为<code>docker</code>这个镜像里面只有客户端的二进制文件，我们需要使用宿主机的<code>Docker Daemon</code>来构建镜像，当然我们也需要在运行 Slave Pod 的节点上拥有访问集群的文件，然后在每个<code>Stage</code>阶段使用特定需要的容器来进行任务的描述即可，所以这几个<code>volumes</code>都是非常重要的</p>
<pre><code class="language-groovy">volumes: [
  hostPathVolume(mountPath: '/root/.m2', hostPath: '/var/run/m2'),
  hostPathVolume(mountPath: '/home/jenkins/.kube', hostPath: '/root/.kube'),
  hostPathVolume(mountPath: '/var/run/docker.sock', hostPath: '/var/run/docker.sock')
]
</code></pre>
<p>另外一个值得注意的就是<code>label</code>标签的定义，我们这里使用 UUID 生成一个随机的字符串，这样可以让 Slave Pod 每次的名称都不一样，而且这样就不会被固定在一个 Pod 上面了，以后有多个构建任务的时候就不会存在等待的情况了，这和我们之前的课程中讲到的固定在一个 label 标签上有所不同。</p>
<p>然后我们将上面的<code>Jenkinsfile</code>文件提交到 Gitlab 代码仓库上：</p>
<pre><code class="language-shell">$ git add Jenkinsfile
$ git commit -m &quot;添加 Jenkinsfile 文件&quot;
$ git push origin master
</code></pre>
<p>然后切换到 Jenkins 页面上，正常情况就可以看到我们的流水线任务<code>polling-app-server</code>已经被触发构建了，然后回到我们的 Kubernetes 集群中可以看到多了一个 slave 开头的 Pod，里面有5个容器，就是我们上面 podTemplate 中定义的4个容器，加上一个默认的 jenkins slave 容器，同样的，构建任务完成后，这个 Pod 也会被自动销毁掉：</p>
<pre><code class="language-shell">$ kubectl get pods -n kube-ops
NAME                                                      READY     STATUS    RESTARTS   AGE
jenkins-7fbfcc5ddc-xsqmt                                  1/1       Running   0          1d
slave-6e898009-62a2-4798-948f-9c80c3de419b-0jwml-6t6hb   5/5       Running   0          36s
......
</code></pre>
<p>正常可以看到 Jenkins 中的任务构建成功了：</p>
<figure data-type="image" tabindex="1"><img src="https://bxdc-static.oss-cn-beijing.aliyuncs.com/images/LVcr9o.jpg" alt="build successfully" loading="lazy"></figure>
<h2 id="pipeline">Pipeline</h2>
<p>第一个阶段：单元测试，我们可以在这个阶段是运行一些单元测试或者静态代码分析的脚本，我们这里直接忽略。</p>
<p>第二个阶段：代码编译打包，我们可以看到我们是在一个<code>maven</code>的容器中来执行的，所以我们只需要在该容器中获取到代码，然后在代码目录下面执行 maven 打包命令即可，如下所示：</p>
<pre><code class="language-groovy">  stage('代码编译打包') {
    try {
      container('maven') {
        echo &quot;2. 代码编译打包阶段&quot;
        sh &quot;mvn clean package -Dmaven.test.skip=true&quot;
      }
    } catch (exc) {
      println &quot;构建失败 - ${currentBuild.fullDisplayName}&quot;
      throw(exc)
    }
  }
</code></pre>
<p>第三个阶段：构建 Docker 镜像，要构建 Docker 镜像，就需要提供镜像的名称和 tag，要推送到 Harbor 仓库，就需要提供登录的用户名和密码，所以我们这里使用到了<code>withCredentials</code>方法，在里面可以提供一个<code>credentialsId</code>为<code>dockerhub</code>的认证信息，如下：</p>
<pre><code class="language-groovy">stage('构建 Docker 镜像') {
  withCredentials([[$class: 'UsernamePasswordMultiBinding',
    credentialsId: 'dockerhub',
    usernameVariable: 'DOCKER_HUB_USER',
    passwordVariable: 'DOCKER_HUB_PASSWORD']]) {
      container('docker') {
        echo &quot;3. 构建 Docker 镜像阶段&quot;
        sh &quot;&quot;&quot;
          docker login ${dockerRegistryUrl} -u ${DOCKER_HUB_USER} -p ${DOCKER_HUB_PASSWORD}
          docker build -t ${image}:${imageTag} .
          docker push ${image}:${imageTag}
          &quot;&quot;&quot;
      }
  }
}
</code></pre>
<p>其中 ${image} 和 ${imageTag} 我们可以在上面定义成全局变量：</p>
<pre><code class="language-groovy">def imageTag = sh(script: &quot;git rev-parse --short HEAD&quot;, returnStdout: true).trim()
def dockerRegistryUrl = &quot;registry.qikqiak.com&quot;
def imageEndpoint = &quot;course/polling-app-server&quot;
def image = &quot;${dockerRegistryUrl}/${imageEndpoint}&quot;
</code></pre>
<p>docker 的用户名和密码信息则需要通过<code>凭据</code>来进行添加，进入 jenkins 首页 -&gt; 左侧菜单<code>凭据</code> -&gt; <code>添加凭据</code>，选择用户名和密码类型的，其中 ID 一定要和上面的<code>credentialsId</code>的值保持一致：</p>
<p><img src="https://bxdc-static.oss-cn-beijing.aliyuncs.com/images/6uXfeq.jpg" alt="add docker hub credential" loading="lazy">add docker hub credential</p>
<p>第四个阶段：运行 kubectl 工具，其实在我们当前使用的流水线中是用不到 kubectl 工具的，那么为什么我们这里要使用呢？这还不是因为我们暂时还没有去写应用的 Helm Chart 包吗？所以我们先去用原始的 YAML 文件来编写应用部署的资源清单文件，这也是我们写出 Chart 包前提，因为只有知道了应用如何部署才可能知道 Chart 包如何编写，所以我们先编写应用部署资源清单。</p>
<p>首先当然就是 Deployment 控制器了，如下所示：（k8s.yaml）</p>
<pre><code class="language-yaml">apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: polling-server
  namespace: course
  labels:
    app: polling-server
spec:
  strategy:
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 1
    type: RollingUpdate
  template:
    metadata:
      labels:
        app: polling-server
    spec:
      restartPolicy: Always
      imagePullSecrets:
        - name: myreg
      containers:
      - image: &lt;IMAGE&gt;:&lt;IMAGE_TAG&gt;
        name: polling-server
        imagePullPolicy: IfNotPresent
        ports:
        - containerPort: 8080
          name: api
        env:
        - name: DB_HOST
          value: mysql
        - name: DB_PORT
          value: &quot;3306&quot;
        - name: DB_NAME
          value: polling_app
        - name: DB_USER
          value: polling
        - name: DB_PASSWORD
          value: polling321

---

kind: Service
apiVersion: v1
metadata:
  name: polling-server
  namespace: course
spec:
  selector:
    app: polling-server
  type:  ClusterIP
  ports:
  - name: api-port
    port: 8080
    targetPort:  api

---
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: mysql
  namespace: course
spec:
  template:
    metadata:
      labels:
        app: mysql
    spec:
      restartPolicy: Always
      containers:
      - name: mysql
        image: mysql:5.7
        imagePullPolicy: IfNotPresent
        ports:
        - containerPort: 3306
          name: dbport
        env:
        - name: MYSQL_ROOT_PASSWORD
          value: rootPassW0rd
        - name: MYSQL_DATABASE
          value: polling_app
        - name: MYSQL_USER
          value: polling
        - name: MYSQL_PASSWORD
          value: polling321
        volumeMounts:
        - name: db
          mountPath: /var/lib/mysql
      volumes:
      - name: db
        hostPath:
          path: /var/lib/mysql

---
kind: Service
apiVersion: v1
metadata:
  name: mysql
  namespace: course
spec:
  selector:
    app: mysql
  type:  ClusterIP
  ports:
  - name: dbport
    port: 3306
    targetPort: dbport
</code></pre>
<p>可以看到我们上面的 YAML 文件中添加使用的镜像是用标签代替的：<code>&lt;IMAGE&gt;:&lt;IMAGE_TAG&gt;</code>，这是因为我们的镜像地址是动态的，下依赖我们在上一个阶段打包出来的镜像地址的，所以我们这里用标签代替，然后将标签替换成真正的值即可，另外为了保证应用的稳定性，我们还在应用中添加了健康检查，所以需要在代码中添加一个健康检查的 Controller：（src/main/java/com/example/polls/controller/StatusController.java）</p>
<pre><code class="language-java">package com.example.polls.controller;

import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping(&quot;/api/_status/healthz&quot;)
public class StatusController {

    @GetMapping
    public String healthCheck() {
        return &quot;UP&quot;;
    }

}
</code></pre>
<p>最后就是环境变量了，还记得前面我们更改了资源文件中数据库的配置吗？（src/main/resources/application.properties）因为要尽量通用，我们在部署应用的时候很有可能已经有一个外部的数据库服务了，所以这个时候通过环境变量传入进来即可。另外由于我们这里使用的是私有镜像仓库，所以需要在集群中提前创建一个对应的 Secret 对象：</p>
<pre><code class="language-shell">$ kubectl create secret docker-registry myreg --docker-server=registry.qikqiak.com --docker-username=DOCKER_USER --docker-password=DOCKER_PASSWORD --docker-email=DOCKER_EMAIL --namespace course
</code></pre>
<p>在代码根目录下面创建一个 manifests 的目录，用来存放上面的资源清单文件，正常来说是不是我们只需要在镜像构建成功后，将上面的 k8s.yaml 文件中的镜像标签替换掉就 OK，所以这一步的动作如下：</p>
<pre><code class="language-groovy">stage('运行 Kubectl') {
  container('kubectl') {
    echo &quot;查看 K8S 集群 Pod 列表&quot;
    sh &quot;kubectl get pods&quot;
    sh &quot;&quot;&quot;
      sed -i &quot;s/&lt;IMAGE&gt;/${image}&quot; manifests/k8s.yaml
      sed -i &quot;s/&lt;IMAGE_TAG&gt;/${imageTag}&quot; manifests/k8s.yaml
      kubectl apply -f k8s.yaml
    &quot;&quot;&quot;
  }
}
</code></pre>
<p>第五阶段：运行 Helm 工具，就是直接使用 Helm 来部署应用了，现在有了上面的基本的资源对象了，要创建 Chart 模板就相对容易了，Chart 模板仓库地址：https://github.com/cnych/polling-helm，我们可以根据<code>values.yaml</code>文件来进行自定义安装，模板中我们定义了可以指定使用外部数据库服务或者内部独立的数据库服务，具体的我们可以去看模板中的定义。首先我们可以先使用这个模板在集群中来测试下。首先在集群中 Clone 上面的 Chart 模板：</p>
<pre><code class="language-shell">$ git clone https://github.com/cnych/polling-helm.git
</code></pre>
<p>然后我们使用内部的数据库服务，新建一个 custom.yaml 文件来覆盖 values.yaml 文件中的值：</p>
<pre><code class="language-yaml">persistence:
  enabled: true
  persistentVolumeClaim:
    database:
      storageClass: &quot;database&quot;

database:
  type: internal
  internal:
    database: &quot;polling&quot;
    # 数据库用户
    username: &quot;polling&quot;
    # 数据库用户密码
    password: &quot;polling321&quot;
</code></pre>
<p>可以看到我们这里使用了一个名为<code>database</code>的 StorgeClass 对象，所以还得创建先创建这个资源对象：</p>
<pre><code class="language-yaml">apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: database
provisioner: fuseim.pri/ifs
</code></pre>
<p>然后我们就可以在 Chart 根目录下面安装应用，执行下面的命令：</p>
<pre><code class="language-shell">$ helm upgrade --install polling -f custom.yaml . --namespace course
Release &quot;polling&quot; does not exist. Installing it now.
NAME:   polling
LAST DEPLOYED: Sat May  4 23:31:42 2019
NAMESPACE: course
STATUS: DEPLOYED

RESOURCES:
==&gt; v1/Pod(related)
NAME                                  READY  STATUS             RESTARTS  AGE
polling-polling-api-6b699478d6-lqwhw  0/1    ContainerCreating  0         0s
polling-polling-ui-587bbfb7b5-xr2ff   0/1    ContainerCreating  0         0s
polling-polling-database-0            0/1    Pending            0         0s

==&gt; v1/Secret
NAME                      TYPE    DATA  AGE
polling-polling-database  Opaque  1     0s

==&gt; v1/Service
NAME                      TYPE       CLUSTER-IP     EXTERNAL-IP  PORT(S)   AGE
polling-polling-api       ClusterIP  10.109.19.220  &lt;none&gt;       8080/TCP  0s
polling-polling-database  ClusterIP  10.98.136.190  &lt;none&gt;       3306/TCP  0s
polling-polling-ui        ClusterIP  10.108.170.43  &lt;none&gt;       80/TCP    0s

==&gt; v1beta2/Deployment
NAME                 DESIRED  CURRENT  UP-TO-DATE  AVAILABLE  AGE
polling-polling-api  1        1        1           0          0s
polling-polling-ui   1        1        1           0          0s

==&gt; v1/StatefulSet
NAME                      DESIRED  CURRENT  AGE
polling-polling-database  1        1        0s

==&gt; v1beta1/Ingress
NAME                     HOSTS              ADDRESS  PORTS  AGE
polling-polling-ingress  ui.polling.domain  80       0s


NOTES:
1. Get the application URL by running these commands:
  http://ui.polling.domain

You have new mail in /var/spool/mail/root
</code></pre>
<blockquote>
<p>注意我们这里安装也是使用的<code>helm upgrade</code>命令，这样有助于安装和更新的时候命令统一。</p>
</blockquote>
<p>安装完成后，查看下 Pod 的运行状态：</p>
<pre><code class="language-shell">$ kubectl get pods -n course
NAME                                   READY     STATUS    RESTARTS   AGE
polling-polling-api-6b699478d6-lqwhw   1/1       Running   0          3m
polling-polling-database-0             1/1       Running   0          3m
polling-polling-ui-587bbfb7b5-xr2ff    1/1       Running   0          3m
</code></pre>
<p>然后我们可以在本地<code>/etc/hosts</code>里面加上<code>http://ui.polling.domain</code>的的映射，这样我们就可以通过这个域名来访问我们安装的应用了，可以注册、登录、发表投票内容了：</p>
<p><img src="https://bxdc-static.oss-cn-beijing.aliyuncs.com/images/oblr3.png" alt="polling app" loading="lazy">polling app</p>
<p>这样我们就完成了使用 Helm Chart 安装应用的过程，但是现在我们使用的包还是直接使用的 git 仓库中的，平常我们正常安装的时候都是使用的 Chart 仓库中的包，所以我们需要将该 Chart 包上传到一个仓库中去，比较幸运的是我们的 Harbor 也是支持 Helm Chart 包的。我们可以选择手动通过 Harbor 的 Dashboard 将 Chart 包进行上传，也可以通过使用<code>Helm Push</code>插件：</p>
<pre><code class="language-shell">$ helm plugin install https://github.com/chartmuseum/helm-push
Downloading and installing helm-push v0.7.1 ...
https://github.com/chartmuseum/helm-push/releases/download/v0.7.1/helm-push_0.7.1_linux_amd64.tar.gz

Installed plugin: push
</code></pre>
<p>当然我们需要首先将 Harbor 提供的仓库添加到 helm repo 中，由于是私有仓库，所以在添加的时候我们需要添加用户名和密码：</p>
<pre><code class="language-shell">$ helm repo add course https://registry.qikqiak.com/chartrepo/course --username=&lt;harbor用户名&gt; --password=&lt;harbor密码&gt;
&quot;course&quot; has been added to your repositories
</code></pre>
<p>这里的 repo 的地址是<code>&lt;Harbor URL&gt;/chartrepo/&lt;Harbor中项目名称&gt;</code>，Harbor 中每个项目是分开的 repo，如果不提供项目名称，则默认使用<code>library</code>这个项目。</p>
<blockquote>
<p>需要注意的是如果你的 Harbor 是采用的自建的 https 证书，这里就需要提供 ca 证书和私钥文件了，否则会出现证书校验失败的错误<code>x509: certificate signed by unknown authority</code>。我们这里是通过<code>cert-manager</code>为 Harbor 提供的一个信任的 https 证书，所以没有指定 ca 证书相关的参数。</p>
</blockquote>
<p>然后我们将上面的<code>polling-helm</code>这个 Chart 包上传到 Harbor 仓库中去：</p>
<pre><code class="language-shell">$ helm push polling-helm course
Pushing polling-0.1.0.tgz to course...
Done.
</code></pre>
<p>这个时候我们登录的 Harbor 仓库中去，查看 course 这个项目下面的<code>Helm Charts</code>就可以发现多了一个 polling 的应用了：</p>
<p><img src="https://bxdc-static.oss-cn-beijing.aliyuncs.com/images/96e57.png" alt="helm chart" loading="lazy">helm chart</p>
<p>我们也可以在右下角看到有添加仓库和安装 Chart 的相关命令。</p>
<p>到这里 Helm 相关的工作就准备好了。那么我们如何在 Jenkins Pipeline 中去使用 Helm 呢？我们可以回顾下，我们平时的一个 CI/CD 的流程：开发代码 -&gt; 提交代码 -&gt; 触发镜像构建 -&gt; 修改镜像tag -&gt; 推送到镜像仓库中去 -&gt; 然后更改 YAML 文件镜像版本 -&gt; 使用 kubectl 工具更新应用。</p>
<p>现在我们是不是直接使用 Helm 了，就不需要去手动更改 YAML 文件了，也不需要使用 kubectl 工具来更新应用了，而是只需要去覆盖下 helm 中的镜像版本，直接 upgrade 是不是就可以达到应用更新的结果了。我们可以去查看下 chart 包的 values.yaml 文件中关于 api 服务的定义：</p>
<pre><code class="language-yaml">api:
  image:
    repository: cnych/polling-api
    tag: 0.0.7
    pullPolicy: IfNotPresent
</code></pre>
<p>我们是不是只需要将上面关于 api 服务使用的镜像用我们这里 Jenkins 构建后的替换掉就可以了，这样我们更改上面的最后<code>运行 Helm</code>的阶段如下：</p>
<pre><code class="language-groovy">stage('运行 Helm') {
  container('helm') {
    echo &quot;更新 polling 应用&quot;
    sh &quot;&quot;&quot;
      helm upgrade --install polling polling --set persistence.persistentVolumeClaim.database.storageClass=database --set database.type=internal --set database.internal.database=polling --set database.internal.username=polling --set database.internal.password=polling321 --set api.image.repository=${image} --set api.image.tag=${imageTag} --set imagePullSecrets[0].name=myreg --namespace course
    &quot;&quot;&quot;
  }
}
</code></pre>
<p>当然我们可以将需要更改的值都放入一个 YAML 之中来进行修改，我们这里通过<code>--set</code>来覆盖对应的值，这样整个 API 服务的完整 Jenkinsfile 文件如下所示：</p>
<pre><code class="language-groovy">def label = &quot;slave-${UUID.randomUUID().toString()}&quot;

def helmLint(String chartDir) {
    println &quot;校验 chart 模板&quot;
    sh &quot;helm lint ${chartDir}&quot;
}

def helmInit() {
  println &quot;初始化 helm client&quot;
  sh &quot;helm init --client-only --stable-repo-url https://mirror.azure.cn/kubernetes/charts/&quot;
}

def helmRepo(Map args) {
  println &quot;添加 course repo&quot;
  sh &quot;helm repo add --username ${args.username} --password ${args.password} course https://registry.qikqiak.com/chartrepo/course&quot;

  println &quot;更新 repo&quot;
  sh &quot;helm repo update&quot;

  println &quot;获取 Chart 包&quot;
  sh &quot;&quot;&quot;
    helm fetch course/polling
    tar -xzvf polling-0.1.0.tgz
    &quot;&quot;&quot;
}

def helmDeploy(Map args) {
    helmInit()
    helmRepo(args)

    if (args.dry_run) {
        println &quot;Debug 应用&quot;
        sh &quot;helm upgrade --dry-run --debug --install ${args.name} ${args.chartDir} --set persistence.persistentVolumeClaim.database.storageClass=database --set api.image.repository=${args.image} --set api.image.tag=${args.tag} --set imagePullSecrets[0].name=myreg --namespace=${args.namespace}&quot;
    } else {
        println &quot;部署应用&quot;
        sh &quot;helm upgrade --install ${args.name} ${args.chartDir} --set persistence.persistentVolumeClaim.database.storageClass=database --set api.image.repository=${args.image} --set api.image.tag=${args.tag} --set imagePullSecrets[0].name=myreg --namespace=${args.namespace}&quot;
        echo &quot;应用 ${args.name} 部署成功. 可以使用 helm status ${args.name} 查看应用状态&quot;
    }
}


podTemplate(label: label, containers: [
  containerTemplate(name: 'maven', image: 'maven:3.6-alpine', command: 'cat', ttyEnabled: true),
  containerTemplate(name: 'docker', image: 'docker', command: 'cat', ttyEnabled: true),
  containerTemplate(name: 'helm', image: 'cnych/helm', command: 'cat', ttyEnabled: true)
], volumes: [
  hostPathVolume(mountPath: '/root/.m2', hostPath: '/var/run/m2'),
  hostPathVolume(mountPath: '/home/jenkins/.kube', hostPath: '/root/.kube'),
  hostPathVolume(mountPath: '/var/run/docker.sock', hostPath: '/var/run/docker.sock')
]) {
  node(label) {
    def myRepo = checkout scm
    def gitCommit = myRepo.GIT_COMMIT
    def gitBranch = myRepo.GIT_BRANCH
    def imageTag = sh(script: &quot;git rev-parse --short HEAD&quot;, returnStdout: true).trim()
    def dockerRegistryUrl = &quot;registry.qikqiak.com&quot;
    def imageEndpoint = &quot;course/polling-api&quot;
    def image = &quot;${dockerRegistryUrl}/${imageEndpoint}&quot;

    stage('单元测试') {
      echo &quot;1.测试阶段&quot;
    }
    stage('代码编译打包') {
      try {
        container('maven') {
          echo &quot;2. 代码编译打包阶段&quot;
          sh &quot;mvn clean package -Dmaven.test.skip=true&quot;
        }
      } catch (exc) {
        println &quot;构建失败 - ${currentBuild.fullDisplayName}&quot;
        throw(exc)
      }
    }
    stage('构建 Docker 镜像') {
      withCredentials([[$class: 'UsernamePasswordMultiBinding',
        credentialsId: 'dockerhub',
        usernameVariable: 'DOCKER_HUB_USER',
        passwordVariable: 'DOCKER_HUB_PASSWORD']]) {
          container('docker') {
            echo &quot;3. 构建 Docker 镜像阶段&quot;
            sh &quot;&quot;&quot;
              docker login ${dockerRegistryUrl} -u ${DOCKER_HUB_USER} -p ${DOCKER_HUB_PASSWORD}
              docker build -t ${image}:${imageTag} .
              docker push ${image}:${imageTag}
              &quot;&quot;&quot;
          }
      }
    }
    stage('运行 Helm') {
      withCredentials([[$class: 'UsernamePasswordMultiBinding',
        credentialsId: 'dockerhub',
        usernameVariable: 'DOCKER_HUB_USER',
        passwordVariable: 'DOCKER_HUB_PASSWORD']]) {
          container('helm') {
            // todo，也可以做一些其他的分支判断是否要直接部署
            echo &quot;4. [INFO] 开始 Helm 部署&quot;
            helmDeploy(
                dry_run     : false,
                name        : &quot;polling&quot;,
                chartDir    : &quot;polling&quot;,
                namespace   : &quot;course&quot;,
                tag         : &quot;${imageTag}&quot;,
                image       : &quot;${image}&quot;,
                username    : &quot;${DOCKER_HUB_USER}&quot;,
                password    : &quot;${DOCKER_HUB_PASSWORD}&quot;
            )
            echo &quot;[INFO] Helm 部署应用成功...&quot;
          }
      }
    }
  }
}
</code></pre>
<p>由于我们没有将 chart 包放入到 API 服务的代码仓库中，这是因为我们这里使用的 chart 包涉及到两个应用，一个 API 服务，一个是前端展示的服务，所以我们这里是通过脚本里面去主动获取到 chart 包来进行安装的，如果 chart 包跟随代码仓库一起管理当然就要简单许多了。</p>
<p>现在我们去更新 Jenkinsfile 文件，然后提交到 gitlab 中，然后去观察下 Jenkins 中的构建是否成功，我们重点观察下 Helm 阶段：</p>
<p><img src="https://bxdc-static.oss-cn-beijing.aliyuncs.com/images/yfilp.png" alt="jenkins helm console" loading="lazy">jenkins helm console</p>
<p>当然我们还可以去做一些必要的判断工作，比如根据分支判断是否需要自动部署等等，同样也可以切换到 Blue Occean 界面查看构建结果。</p>
<p><img src="https://bxdc-static.oss-cn-beijing.aliyuncs.com/images/sq24p.jpg" alt="jenkins blue occean" loading="lazy">jenkins blue occean</p>
<p>现在大家可以尝试去修改下代码，然后提交代码到 gitlab 上，观察下 Jenkins 是否能够自动帮我们完成整个 CI/CD 的过程。</p>
<p>作业：现在还有一个前端展示的项目：https://github.com/cnych/polling-app-client，大家针对这个项目使用上面的 gitlab + jenkins + harbor + helm 来完成一个 Jenkins Pipeline 流水线的编写，尝试去修改下前端页面内容，看是否能够生效。</p>
<h2 id="相关链接">相关链接：</h2>
<ul>
<li>https://github.com/cnych/polling-helm</li>
<li>https://github.com/cnych/polling-app-server</li>
<li>https://github.com/cnych/polling-app-client</li>
</ul>

          
          <p class="next-post">下一篇：
            <a href="https://blog.jwangkun.com/post/UtzHh7Dxw/">
              <span class="post-title">
                在现有 Kubernetes 集群上安装 KubeSphere&rarr;
              </span>
            </a>
          </p>
        
        <div class="comment">
          
        </div>
      </div>
    </div>
  </article>
 <!-- Footer -->
  <footer>
    <div class="container">
      <div class="row">
        <div class="col-lg-8 col-md-10 mx-auto">
          <ul class="list-inline text-center">
            
            
              
            
              
            
              
            
              
            
              
            
              
            
              
              <li class="list-inline-item">
              <a href="https://blog.jwangkun.com/atom.xml" target="_blank">
                <span class="fa-stack fa-lg">
                  <i class="fas fa-circle fa-stack-2x"></i>
                  <i class="fas fa-rss fa-stack-1x fa-inverse"></i>
                </span>
              </a>
              </li>
          </ul>
          <p class="copyright text-muted">Copyright &copy;<span>John Wong&#39;s Blog</span><br><a href="https://github.com/getgridea/gridea" class="Themeinfo">Powered by Gridea</a></p>
        </div>
      </div>
    </div>
   </footer>
  <!-- Bootstrap core JavaScript -->
  <script src="https://cdn.bootcss.com/twitter-bootstrap/4.3.1/js/bootstrap.bundle.min.js"></script>
  <!-- <script src="https://blog.jwangkun.com/media/scripts/bootstrap.bundle.min.js"></script> -->
  <!-- Bootstrap core JavaScript -->
  <script src="https://cdn.jsdelivr.net/gh/Alanrk/clean-cdn@1.0/scripts/clean-blog.min.js"></script>
  <!-- <script src="https://blog.jwangkun.com/media/scripts/clean-blog.min.js"></script> -->
  <script src="//instant.page/3.0.0" type="module" defer integrity="sha384-OeDn4XE77tdHo8pGtE1apMPmAipjoxUQ++eeJa6EtJCfHlvijigWiJpD7VDPWXV1"></script>
  <style type="text/css">a.back_to_top{text-decoration:none;position:fixed;bottom:40px;right:30px;background:#f0f0f0;height:40px;width:40px;border-radius:50%;line-height:36px;font-size:18px;text-align:center;transition-duration:.5s;transition-propety:background-color;display:none}a.back_to_top span{color:#888}a.back_to_top:hover{cursor:pointer;background:#dfdfdf}a.back_to_top:hover span{color:#555}@media print,screen and(max-width:580px){.back_to_top{display:none!important}}</style>
<a id="back_to_top" href="#" class="back_to_top">
  <span>▲</span></a>
<script>$(document).ready((function(_this) {
    return function() {
      var bt;
      bt = $('#back_to_top');
      if ($(document).width() > 480) {
        $(window).scroll(function() {
          var st;
          st = $(window).scrollTop();
          if (st > 30) {
            return bt.css('display', 'block')
          } else {
            return bt.css('display', 'none')
          }
        });
        return bt.click(function() {
          $('body,html').animate({
            scrollTop: 0
          },
          800);
          return false
        })
      }
    }
  })(this));
  var _hmt = _hmt || [];
(function() {
  var hm = document.createElement("script");
  hm.src = "https://hm.baidu.com/hm.js?84ab85460bfbe79dbe5776a1df139a8f";
  var s = document.getElementsByTagName("script")[0]; 
  s.parentNode.insertBefore(hm, s);
})();
  </script>
  
<script type="text/javascript" src="https://v1.cnzz.com/z_stat.php?id=1279350888&web_id=1279350888"></script>

  </body>
</html>

